Effect persistence layer - #83
Conversation
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>
|
Cursor Agent can help with this pull request. Just |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Adopt an Effect-based SQLite persistence layer for server state and route all CRUD and event flow through
|
| import path from "node:path"; | ||
|
|
||
| export function normalizeCwd(rawCwd: string): string { | ||
| const resolved = path.resolve(rawCwd.trim()); |
There was a problem hiding this comment.
🟡 Medium
domain/projects.ts:5 On POSIX, directory names can contain leading/trailing whitespace (e.g., "repo "), so .trim() may corrupt valid paths. If this is intentional input sanitization, consider documenting that assumption; otherwise, consider removing .trim().
| const resolved = path.resolve(rawCwd.trim()); | |
| const resolved = path.resolve(rawCwd); |
🚀 Want me to fix this? Reply ex: "fix it for me".
🤖 Prompt for AI
In file apps/server/src/persistence/domain/projects.ts around line 5:
On POSIX, directory names can contain leading/trailing whitespace (e.g., `"repo "`), so `.trim()` may corrupt valid paths. If this is intentional input sanitization, consider documenting that assumption; otherwise, consider removing `.trim()`.
Evidence trail:
apps/server/src/persistence/domain/projects.ts line 5 at commit 40f9885dceed14d5af286fb33d23b48e7fd3e974 shows: `const resolved = path.resolve(rawCwd.trim());` - confirming `.trim()` is called on the path input. POSIX filesystem specification allows whitespace characters in filenames/directory names.
| return [...new Set(runningTerminalIds)] | ||
| .map((id) => id.trim()) | ||
| .filter((id) => id.length > 0 && validTerminalIds.has(id)) |
There was a problem hiding this comment.
🟡 Medium
domain/threads.ts:25 Deduplication via Set happens before trim(), so "a" and " a" both survive deduplication and become duplicate "a" entries after trimming. Consider trimming before deduplicating, similar to normalizeTerminalIds.
| return [...new Set(runningTerminalIds)] | |
| .map((id) => id.trim()) | |
| .filter((id) => id.length > 0 && validTerminalIds.has(id)) | |
| return [...new Set(runningTerminalIds.map((id) => id.trim()).filter((id) => id.length > 0))] | |
| .filter((id) => validTerminalIds.has(id)) |
🚀 Want me to fix this? Reply ex: "fix it for me".
🤖 Prompt for AI
In file apps/server/src/persistence/domain/threads.ts around lines 25-27:
Deduplication via `Set` happens before `trim()`, so `"a"` and `" a"` both survive deduplication and become duplicate `"a"` entries after trimming. Consider trimming before deduplicating, similar to `normalizeTerminalIds`.
Evidence trail:
apps/server/src/persistence/domain/threads.ts lines 24-28 (commit 40f9885): `return [...new Set(runningTerminalIds)].map((id) => id.trim())...` shows Set deduplication before trim().
apps/server/src/persistence/domain/threads.ts lines 7-8 (commit 40f9885): `...new Set(ids.map((id) => id.trim()).filter...)` shows normalizeTerminalIds correctly trims before deduplicating.
| private readonly sessionThreadIds = new Map<string, string>(); | ||
| private readonly runtimeThreadIds = new Map<string, string>(); | ||
| private readonly stateEventsQueue = Effect.runSync(Queue.unbounded<StateEvent>()); | ||
| private readonly stateEventsBridge = Effect.runFork(this.runStateEventsBridge()); |
There was a problem hiding this comment.
🟡 Medium
src/persistenceService.ts:195 The stateEventsBridge fiber is started in a field initializer before the constructor body runs. If runPersistenceMigrations throws, the fiber is never interrupted because the caller never receives an instance to call close(). Consider moving fiber creation after migration succeeds, or interrupting the fiber in the catch block.
🚀 Want me to fix this? Reply ex: "fix it for me".
🤖 Prompt for AI
In file apps/server/src/persistenceService.ts around line 195:
The `stateEventsBridge` fiber is started in a field initializer before the constructor body runs. If `runPersistenceMigrations` throws, the fiber is never interrupted because the caller never receives an instance to call `close()`. Consider moving fiber creation after migration succeeds, or interrupting the fiber in the `catch` block.
Evidence trail:
apps/server/src/persistenceService.ts lines 194-235 at commit 40f9885dceed14d5af286fb33d23b48e7fd3e974:
- Line 195: `private readonly stateEventsBridge = Effect.runFork(this.runStateEventsBridge());` (field initializer starts fiber)
- Lines 198-217: constructor body, with try-catch around `runPersistenceMigrations` at lines 202-213
- Lines 204-213: catch block closes db but does not interrupt stateEventsBridge
- Lines 219-235: close() method interrupts fiber at line 230, but caller never gets instance if constructor throws
| Effect.runSync(Scope.close(this.scope, Exit.void)); | ||
| } | ||
|
|
||
| runWithSqlClient<A, E>(effect: Effect.Effect<A, E, SqlClient.SqlClient>): A { |
There was a problem hiding this comment.
🟢 Low
persistence/sqliteAdapter.ts:150 runWithSqlClient uses Effect.runSync internally, which throws on async effects. Consider documenting this sync-only restriction in the method signature (e.g., rename to runSyncWithSqlClient) or updating the implementation to handle async effects.
🚀 Want me to fix this? Reply ex: "fix it for me".
🤖 Prompt for AI
In file apps/server/src/persistence/sqliteAdapter.ts around line 150:
`runWithSqlClient` uses `Effect.runSync` internally, which throws on async effects. Consider documenting this sync-only restriction in the method signature (e.g., rename to `runSyncWithSqlClient`) or updating the implementation to handle async effects.
Evidence trail:
apps/server/src/persistence/sqliteAdapter.ts lines 150-152 (runWithSqlClient calls this.runEffect), lines 173-176 (runEffect uses Effect.runSync on line 175) at commit 40f9885dceed14d5af286fb33d23b48e7fd3e974
| [afterSeq], | ||
| ) | ||
| .unprepared) as StateEventRow[]; | ||
| return decodeStateEventRows(rows).map((row) => |
There was a problem hiding this comment.
🟠 High
repos/stateEventsRepo.ts:93 StateEventRowSchema expects seq: Number and payload_json: String, but the database may return bigint for sequences and NULL for payloads (when JSON.stringify(undefined) is inserted). Consider updating the schema to handle bigint (via Schema.Union) and nullable strings.
🚀 Want me to fix this? Reply ex: "fix it for me".
🤖 Prompt for AI
In file apps/server/src/persistence/repos/stateEventsRepo.ts around line 93:
`StateEventRowSchema` expects `seq: Number` and `payload_json: String`, but the database may return `bigint` for sequences and `NULL` for payloads (when `JSON.stringify(undefined)` is inserted). Consider updating the schema to handle `bigint` (via `Schema.Union`) and nullable strings.
Evidence trail:
apps/server/src/persistence/schema.ts:17 - `NumberOrBigIntSchema = Schema.Union([Schema.Number, Schema.BigInt])`
apps/server/src/persistence/schema.ts:34-40 - `StateEventRowSchema` with `seq: Schema.Number` and `payload_json: Schema.String`
apps/server/src/persistence/schema.ts:42-44 - `StateSeqRowSchema` uses `Schema.optional(Schema.NullOr(NumberOrBigIntSchema))` for seq
apps/server/src/persistence/schema.ts:24-27 - `CompletedProviderItemRowSchema` uses `Schema.NullOr(Schema.String)` for payload_json
apps/server/src/persistence/repos/stateEventsRepo.ts:41-52 - `appendStateEvent` with `payload: unknown` and `JSON.stringify(input.payload)`
apps/server/src/persistence/repos/stateEventsRepo.ts:93 - `decodeStateEventRows(rows)` usage
| const normalized = diff.replace(/\r\n/g, "\n"); | ||
| const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); | ||
| if (bPath?.[1]) return bPath[1]; | ||
| const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m); |
There was a problem hiding this comment.
🟡 Medium
domain/turnSummaries.ts:8 Suggestion: make parsePathFromDiff resilient to renames and deletions. Capture the destination path from the diff --git header (avoid the backreference), and when +++ is /dev/null, derive the path from the --- a/... line so deleted files aren’t omitted.
| const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m); | |
| const gitHeader = normalized.match(/^diff --git a\/.+ b\/(.+)$/m); |
🚀 Want me to fix this? Reply ex: "fix it for me".
🤖 Prompt for AI
In file apps/server/src/persistence/domain/turnSummaries.ts around line 8:
Suggestion: make `parsePathFromDiff` resilient to renames and deletions. Capture the destination path from the `diff --git` header (avoid the backreference), and when `+++` is `/dev/null`, derive the path from the `--- a/...` line so deleted files aren’t omitted.
Evidence trail:
apps/server/src/persistence/domain/turnSummaries.ts lines 4-15 at commit 40f9885dceed14d5af286fb33d23b48e7fd3e974. Line 8 shows backreference `\1` in regex `/^diff --git a\/(.+) b\/\1$/m` which prevents rename matching. Lines 10-13 show that when `+++ /dev/null` is detected, the function returns `null` instead of deriving path from `--- a/...` line.
| export function inferProjectName(cwd: string): string { | ||
| const name = path.basename(cwd); | ||
| return name.length > 0 ? name : "project"; | ||
| } |
There was a problem hiding this comment.
🟢 Low
domain/projects.ts:21 On Windows, path.basename returns empty for drive roots like D:\ or E:\, so all root paths get the same name "project". Consider extracting the drive letter (e.g., "D-root") to avoid collisions when multiple drive roots are registered.
| export function inferProjectName(cwd: string): string { | |
| const name = path.basename(cwd); | |
| return name.length > 0 ? name : "project"; | |
| } | |
| export function inferProjectName(cwd: string): string { | |
| const name = path.basename(cwd); | |
| if (name.length > 0) { | |
| return name; | |
| } | |
| // Handle Windows drive roots (e.g., "C:\" -> "C-root") | |
| if (process.platform === "win32") { | |
| const drive = path.parse(cwd).root.replace(/[:\\]/g, "").toUpperCase(); | |
| if (drive) { | |
| return `${drive}-root`; | |
| } | |
| } | |
| return "project"; | |
| } |
🚀 Want me to fix this? Reply ex: "fix it for me".
🤖 Prompt for AI
In file apps/server/src/persistence/domain/projects.ts around lines 21-24:
On Windows, `path.basename` returns empty for drive roots like `D:\` or `E:\`, so all root paths get the same name `"project"`. Consider extracting the drive letter (e.g., `"D-root"`) to avoid collisions when multiple drive roots are registered.
Evidence trail:
apps/server/src/persistence/domain/projects.ts lines 21-24 at commit 40f9885dceed14d5af286fb33d23b48e7fd3e974. The `inferProjectName` function uses `path.basename(cwd)` and falls back to `"project"` when the result is empty. Node.js `path.basename` behavior for Windows drive roots like `D:\` returns empty string (documented behavior in Node.js path module).
| continue; | ||
| } | ||
|
|
||
| if (char === ";" && !inSingleQuote && !inDoubleQuote && !inBacktick) { |
There was a problem hiding this comment.
🟡 Medium
persistence/sqliteAdapter.ts:72 Semicolons inside SQL comments (-- or /* */) will incorrectly split statements, causing syntax errors. Consider adding comment detection to skip semicolons within -- line comments and /* */ block comments.
🚀 Want me to fix this? Reply ex: "fix it for me".
🤖 Prompt for AI
In file apps/server/src/persistence/sqliteAdapter.ts around line 72:
Semicolons inside SQL comments (`--` or `/* */`) will incorrectly split statements, causing syntax errors. Consider adding comment detection to skip semicolons within `--` line comments and `/* */` block comments.
Evidence trail:
apps/server/src/persistence/sqliteAdapter.ts lines 47-90 at commit 40f9885dceed14d5af286fb33d23b48e7fd3e974. Specifically: lines 51-53 show only quote tracking (inSingleQuote, inDoubleQuote, inBacktick), line 72 shows the semicolon split condition only checks these three flags, no comment detection variables or logic exists in the function.
| if (typeof value === "bigint") { | ||
| return Number(value); | ||
| } |
There was a problem hiding this comment.
🟡 Medium
persistence/sqliteAdapter.ts:96 Converting bigint to number via Number(value) silently loses precision for values exceeding Number.MAX_SAFE_INTEGER. Consider checking bounds and throwing an error, or returning bigint to preserve precision for large lastInsertRowid values.
- if (typeof value === "bigint") {
- return Number(value);
- }
+ if (typeof value === "bigint") {
+ if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < BigInt(Number.MIN_SAFE_INTEGER)) {
+ throw new RangeError(`Value ${value} exceeds safe integer range`);
+ }
+ return Number(value);
+ }🚀 Want me to fix this? Reply ex: "fix it for me".
🤖 Prompt for AI
In file apps/server/src/persistence/sqliteAdapter.ts around lines 96-98:
Converting `bigint` to `number` via `Number(value)` silently loses precision for values exceeding `Number.MAX_SAFE_INTEGER`. Consider checking bounds and throwing an error, or returning `bigint` to preserve precision for large `lastInsertRowid` values.
Evidence trail:
apps/server/src/persistence/sqliteAdapter.ts lines 92-100 (toSafeInteger function with bigint->Number conversion), lines 154-167 (runStatement using toSafeInteger for lastInsertRowid). Commit 40f9885dceed14d5af286fb33d23b48e7fd3e974.
This pull request contains changes generated by a Cursor Cloud Agent